-
Notifications
You must be signed in to change notification settings - Fork 2.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a multistep dialog component #4462
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for contributing! I've provided some initial comments but ultimately defer to @adidahiya. Also @ycvfu if you want to offload this PR just let me know--I'm happy to own any additional changes to get it over the line after the holidays.
The children you provide to this component are rendered as contents inside the | ||
`Classes.DIALOG` element. Typically, you will want to render a panel with | ||
`Classes.DIALOG_BODY` that contains the body content for each step. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should explain a little more about the Step
interface similar to the docs for Tab
. Also probably worth clarifying that only children of type Step
will be rendered.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, I added info under line 54.
/** | ||
* Props for the button to display on the final step. To illustrate, this may be a submit button. | ||
*/ | ||
finalButtonProps?: IFinalButtonProps; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know there was some earlier discussion about not using Partial<IButtonProps>
here because we wanted to prevent users from changing things like button size, but it's a bit difficult to interpret what these IFinalButtonProps
actually includes based on the type declaration. I would be in favor of Partial<IButtonProps>
or at least adding some more clarity in the comment.
} | ||
|
||
function isNextButtonEnabled(step: StepElement) { | ||
return step.props.nextButtonEnabledByDefault !== undefined ? step.props.nextButtonEnabledByDefault : true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return step.props.nextButtonEnabledByDefault !== undefined ? step.props.nextButtonEnabledByDefault : true; | |
return step.props.nextButtonEnabledByDefault ?? true; |
private renderLeftPanel() { | ||
return ( | ||
<div className={Classes.MULTISTEP_DIALOG_LEFT_PANEL}> | ||
{React.Children.toArray(this.props.children).filter(isStepElement).map(this.renderStep)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
{React.Children.toArray(this.props.children).filter(isStepElement).map(this.renderStep)} | |
{this.getStepChildren().map(this.renderStep)} |
const hasBeenViewed = this.state.lastViewedIndex >= index; | ||
const currentlySelected = this.state.selectedIndex === index; | ||
return ( | ||
<div className={classNames(Classes.STEP_CONTAINER, hasBeenViewed && Classes.ACTIVE)} key={index}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<div className={classNames(Classes.STEP_CONTAINER, hasBeenViewed && Classes.ACTIVE)} key={index}> | |
<div className={classNames(Classes.STEP_CONTAINER, { [Classes.ACTIVE]: hasBeenViewed })} key={index}> |
Looks like other places in BP use this syntax for conditional classes but defer to @adidahiya
if (this.props.finalButtonProps !== undefined) { | ||
return <Button key="final" {...this.props.finalButtonProps} />; | ||
} else { | ||
return <Button intent="danger" key="final" onClick={this.props.onClose} text="Submit" />; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (this.props.finalButtonProps !== undefined) { | |
return <Button key="final" {...this.props.finalButtonProps} />; | |
} else { | |
return <Button intent="danger" key="final" onClick={this.props.onClose} text="Submit" />; | |
} | |
return <Button intent="primary" key="final" onClick={this.props.onClose} text="Submit" {...this.props.finalButtonProps} /> |
Similar to how buttonProps
are handled in TimezonePicker
Also, don't think the submit button should default to intent "danger"
/** | ||
* Indicates whether the next button should be enabled by default on this step | ||
*/ | ||
nextButtonEnabledByDefault?: boolean; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wondering if we should just expose nextButtonProps
on MultistepDialog
and make the next button fully controlled. Then we wouldn't need nextButtonEnabledByDefault
or updateDialog
, and Step
would be a purely presentational component that could just render its children. If the next button needs to be disabled at the start of certain steps, the client would do that in their change handler for MultistepDialog's onChange
. Since the parent component of MultistepDialog
is ultimately responsible for keeping track of what data has been entered in the dialog, it might make sense to just allow it to control when the next button is enabled as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, maybe we can expose a disableNext prop in the MultistepDialog instead of exposing nextButtonProps. I don't think that the user should be able to control any aspect of this button except its enabled state. If the user could change the button design such as the text, intent, etc. it would enable them to deviate from the Blueprint design, and I can't think of a case where this would be desirable (since we want to standardize this). For instance, enabling them to change a button on step 3 out of 5 to show "submit" would break the flow and cause confusion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, I think just exposing disableNext
on MultistepDialog
is sufficient! In that case, we should remove this prop from Step
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah! My bad, I missed that. I fixed it in the latest revision.
} | ||
|
||
.#{$ns}-multistep-dialog-left-panel { | ||
border-radius: 0 $dialog-border-radius 0 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need this given that this div doesn't have a background color?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good, mostly just comments on completing the transition to make disableNext
controlled at the dialog level rather than handled by each step.
/** | ||
* Indicates whether the next button should be enabled by default on this step | ||
*/ | ||
nextButtonEnabledByDefault?: boolean; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, I think just exposing disableNext
on MultistepDialog
is sufficient! In that case, we should remove this prop from Step
, right?
} | ||
} | ||
|
||
function isNextButtonEnabled(step: StepElement) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No longer needed if we're exposing the disableNext
prop
} | ||
} | ||
|
||
private getDefaultState() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we only need to define lastViewedIndex
and selectedIndex
, probably a little clearer to just define those in an INITIAL_STATE
const and then set state to INITIAL_STATE
in the constructor and componentDidUpdate
intent="primary" | ||
key="next" | ||
onClick={this.handleClickNext()} | ||
text="Next" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@adidahiya how does BP handle localization? It seems like for most components the client supplies all of the copy. Should that also be the case here (at least making it possible to override the button text)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we need to add a prop to override the button text for i18n. See my comment on IMultistepDialogProps
.
|
||
export type StepId = string | number; | ||
|
||
export interface IMultistepDialogPanelProps { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No longer used?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking great so far! The docs example is currently pretty simple, but we can flesh it out more in a future PR.
It's a little unfortunate how the header icon is misaligned with the step icons here. If we're going to keep the padding this way, we should change the header icon in the example to something that's not a circle.
align-items: center; | ||
background-color: $light-gray5; | ||
border-radius: $step-radius; | ||
cursor: not-allowed; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this is strictly necessary, it can just be grayed out and have a normal pointer with no hover state. Also, what about previous steps? Can't you click on those?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On further review, I realized the semantics of "active" here are a little different from what I expected. That's fine, I think what you have works. but I think it would help to leave a code comment along the lines of "by default, steps are inactive until they are visited"
|
||
public constructor(props: IMultistepDialogProps) { | ||
super(props); | ||
this.state = INITIAL_STATE; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: instead of using a constructor, we can just write
public state: IMultistepDialogState = {
lastViewedIndex: 0,
selectedIndex: 0,
};
|
||
public componentDidUpdate(prevProps: IMultistepDialogProps) { | ||
if (!prevProps.isOpen && this.props.isOpen) { | ||
this.setState(INITIAL_STATE); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we always want to reset to initial state when a dialog is re-opened? Let's document this design decision somewhere in the docs. Maybe this behavior should be controlled by a prop in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, yeah I made the assumption here that if we closed a dialog or clicked away from it, we would want any progress would be lost. We can expose a prop like "resetOnClose" to enable the user to control this.
|
||
private handleClickStep = (index: number) => { | ||
if (index > this.state.lastViewedIndex) { | ||
return undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this code path doesn't need to return a value; I would either return;
here or reverse the condition to if (index <= this.state.lastViewedIndex)
intent="primary" | ||
key="next" | ||
onClick={this.handleClickNext()} | ||
text="Next" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we need to add a prop to override the button text for i18n. See my comment on IMultistepDialogProps
.
/** | ||
* Whether to disable the next button in the panel footer. | ||
*/ | ||
disableNext?: boolean; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I propose making this a little more flexible and solving the problem of the button text mentioned below with a props object here.
nextButtonProps?: Partial<Pick<IButtonProps, "disabled" | "text">>;
}; | ||
} | ||
|
||
private getFinalButton() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: renderFinalButton()
since this returns JSX
|
||
/** | ||
* Panel content, rendered by the parent `MultistepDialog` when this step is active. | ||
* If omitted, no panel will be rendered for this step. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what does this mean / look like? why wouldn't there be a panel?
} | ||
|
||
@polyfill | ||
export class Step extends AbstractPureComponent2<IStepProps> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return ( | ||
<div className={classNames(Classes.STEP_CONTAINER, { [Classes.ACTIVE]: hasBeenViewed })} key={index}> | ||
<div | ||
className={classNames(Classes.STEP, { [Classes.ACTIVE]: hasBeenViewed })} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need all 4 active classes? I think we can remove this one and the one on L113 and change the Sass to be something like:
.step {
.active & {
...
}
}
cursor: not-allowed; | ||
display: flex; | ||
margin: 4px; | ||
padding: 6px 14px; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed padding to better align with the title icon.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks good! thanks for finishing this up @ycvfu 🎉
This PR adds a multistep dialog component.
Checklist
Changes proposed in this pull request:
This PR aims to create a new multistep dialog component that enables the users to move through a sequence of steps in a dialog, each of which presents a different panel. It is implemented as a wrapper around Dialog.
This PR is an updated version of #4455 from a forked repository. I am temporarily keeping #4455 open as other contributors may be looking at it (@kmblake are you planning on updating the old PR/referencing the old PR, or is it okay for me to close it?).
Reviewers should focus on:
The dialog should present to the user a summary of steps on the left and the main panel associated with the currently selected panel on the right. When the user is on the first step, there should only be a 'next' or 'submit' footer button as appropriate. When the user is on the last step, there should only be a 'submit' footer button and potentially a 'back' footer button. When the user is on any page in between, they should see a back and next button.
The user also has the option of navigating between viewed steps on the left panel by clicking on a step. Only steps that have been previously accessed via clicking the "next" footer button can be clicked on. The user can select any pre-existing steps to see the panel.
All regular functionalities from Dialog should also be present.
Screenshot